本日的目標會針對處理活動管理頁面作處理。
這次的實做步驟,會先從一些資料存取的邏輯開始實做。然後開始實做 layout。最後邏輯放置對應的生命周期函數。
import { ApiResponse } from './api';
export type EventResponse = ApiResponse<Event>;
export type EventListResponse = ApiResponse<{events: Event[], pageInfo: PageInfo}>;
export type DeleteEventResponse = ApiResponse<{id: string}>;
export type Event = {
id: string
name: string
location: string
totalTicketsPurchased: number
totalTicketsEntered: number
startDate: string
createdAt: string
updatedAt: string
}
export type PageInfo = {
total: number
offset: number
limit: number
}
基本上有分為單一個 Event 與存取多個 Event 兩類。分別別使用 EventResponse 與 EventListResponse 來存放。
import { DeleteEventResponse, EventListResponse, EventResponse } from '@/types/event';
import { Api } from './api';
async function createOne(name: string, location: string, date: string): Promise<EventResponse> {
return Api.post('/events', {
name: name,
location: location,
startDate: date
});
}
async function getOne(eventId: string): Promise<EventResponse> {
return Api.get(`/events/${eventId}`);
}
async function getAll(): Promise<EventListResponse> {
return Api.get(`/events`);
}
async function updateOne(id: string, name: string, location: string, date: string): Promise<EventResponse> {
return Api.patch(`/events/${id}`, { name, location, startDate: date});
}
async function deleteOne(id: string): Promise<DeleteEventResponse> {
return Api.delete(`/events/${id}`);
}
const eventService = {
createOne,
getOne,
getAll,
updateOne,
deleteOne,
}
export { eventService }
把對 event 相關的操作封裝在 eventService 之內。把存取 api 的邏輯放在裡面。
import { Button } from '@/components/Button';
import { Divider } from '@/components/Divider';
import { HStack } from '@/components/HStack';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { useAuth } from '@/context/AuthContext';
import { eventService } from '@/services/event';
import { Event } from '@/types/event';
import { UserRole } from '@/types/user';
import { useFocusEffect } from '@react-navigation/native';
import { router, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { FlatList, TouchableOpacity } from 'react-native';
import Toast from 'react-native-root-toast';
export default function EventsScreen() {
const {user} = useAuth();
const navigation = useNavigation();
const [isLoading, setIsLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]);
function onGotoEventPage(id: string) {
if (user?.role === UserRole.Admin) {
router.push(`/(events)/event/${id}`);
}
}
function buyTicket(id: string) {
try {
// TODO: await ticket service
} catch(error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
}
}
const fetchEvents = async () => {
try {
setIsLoading(true);
const response = await eventService.getAll();
setEvents(response.data.events);
} catch (error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
} finally {
setIsLoading(false);
}
};
useFocusEffect(useCallback(()=>{
fetchEvents()
}, []))
useEffect(() => {
navigation.setOptions({
headerTitle: 'Events',
headerRight: user?.role === UserRole.Admin ? headerRight:null
});
}, [navigation, user]);
return <VStack flex={1} p={20} pb={0} gap={20}>
<HStack alignItems='center' justifyContent='center'>
<Text fontSize={18} bold>{events.length} Events</Text>
</HStack>
<FlatList
data={events}
keyExtractor={({id})=> id}
onRefresh={fetchEvents}
refreshing={isLoading}
ItemSeparatorComponent={()=> <VStack h={20}/>}
renderItem={({item: event})=> (
<VStack
gap={20}
p={20}
style={{
backgroundColor: 'white',
borderRadius: 20,
}}
key={event.id}
>
<TouchableOpacity onPress={()=>onGotoEventPage(event.id)}>
<HStack alignItems='center' justifyContent='space-between'>
<HStack alignItems='center'>
<Text fontSize={26} bold>{event.name}</Text>
<Text fontSize={26} bold>|</Text>
<Text fontSize={16} bold>{event.location}</Text>
</HStack>
{user?.role === UserRole.Admin && <TabBarIcon size={24} name='chevron-forward'/>}
</HStack>
</TouchableOpacity>
<Divider/>
<HStack justifyContent='space-between'>
<Text bold fontSize={16} color='gray'> Sold: {event.totalTicketsPurchased}</Text>
<Text bold fontSize={16} color='green'> Entered: {event.totalTicketsEntered}</Text>
</HStack>
{user?.role === UserRole.Attendee &&
<VStack>
<Button
variant='outlined'
disabled={isLoading}
onPress={() => buyTicket(event.id)}
>
Buy Ticket
</Button>
</VStack>
}
<Text fontSize={13} color='gray'>{event.startDate}</Text>
</VStack>
)}
/>
</VStack>
;
}
const headerRight = () => {
return <TabBarIcon
size={32}
name='add-circle-outline'
onPress={()=>router.push('/(events)/new')}
/>
}
這個畫面需要處理的是多個 event 的載入還有綁定一些對於單個 event 的事件操作。使用 useEffect 來處理 navigation 。使用 useFocusEffect 來處理每次觸發 api loading 多個 event 的動作。透過 icon 綁定載入單個 event 頁面的邏輯。
根據 手機平台切換不同 DatetimePicker 元件
import { Platform } from 'react-native';
import { HStack } from './HStack';
import { Text } from './Text';
import { Button } from './Button';
import RNDateTimePicker ,{ DateTimePickerAndroid } from '@react-native-community/datetimepicker';
interface DateTimePickerProps {
onChange: (date: Date) => void;
currentDate: Date;
}
export default function DateTimePicker(props: DateTimePickerProps) {
if (Platform.OS === 'android') {
return <AndroidDateTimePicker {...props}/>
}
if (Platform.OS === 'ios') {
return <IOSDateTimePicker {...props}/>
}
return null;
}
export const AndroidDateTimePicker =({onChange, currentDate}: DateTimePickerProps ) => {
const showDateTimePicker = () => {
DateTimePickerAndroid.open({
value: currentDate,
onChange: (_, date?: Date) => onChange(date || new Date()),
mode: 'date',
minimumDate: new Date()
});
};
return (
<HStack p={10} alignItems='center' justifyContent='space-between'>
<Text>{currentDate.toLocaleDateString()}</Text>
<Button variant='outlined' onPress={showDateTimePicker}>Open Calendar</Button>
</HStack>
)
}
export const IOSDateTimePicker = ({onChange, currentDate}: DateTimePickerProps ) => {
return (
<RNDateTimePicker
style={{ alignSelf: 'flex-start'}}
accentColor='black'
minimumDate={new Date()}
value={currentDate}
mode='date'
display='default'
onChange={(_, date?: Date) => onChange(date || new Date())}
/>
)
}
設定新增 Event 的畫面
import { Button } from '@/components/Button';
import DateTimePicker from '@/components/DatetimePicker';
import { Input } from '@/components/Input';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { eventService } from '@/services/event';
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import Toast from 'react-native-root-toast';
export default function NewEvent() {
const navigation = useNavigation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [date, setDate] = useState(new Date());
async function onSubmit() {
try {
setIsSubmitting(true);
await eventService.createOne(name, location, date.toISOString());
router.back();
} catch (error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
} finally {
setIsSubmitting(false);
}
}
function onChangeDate(date?:Date) {
setDate(date || new Date());
}
useEffect(()=>{
navigation.setOptions({
headerTitle: 'New Event',
});
}, [])
return (
<VStack m={20} flex={1} gap={30}>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Name
</Text>
<Input
value={name}
onChangeText={setName}
placeholder='Name'
placeholderTextColor='darkgray'
h={48}
p={14}
/>
</VStack>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Location
</Text>
<Input
value={location}
onChangeText={setLocation}
placeholder='Location'
placeholderTextColor='darkgray'
h={48}
p={14}
/>
</VStack>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Date
</Text>
<DateTimePicker
onChange={onChangeDate}
currentDate={date}
/>
</VStack>
<Button
mt={'auto'}
isLoading={isSubmitting}
disabled={isSubmitting}
onPress={onSubmit}
>
Save
</Button>
</VStack>
);
}
import { Button } from '@/components/Button';
import DateTimePicker from '@/components/DatetimePicker';
import { Input } from '@/components/Input';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { eventService } from '@/services/event';
import { Event } from '@/types/event';
import { useFocusEffect } from '@react-navigation/native';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Alert } from 'react-native';
import Toast from 'react-native-root-toast';
export default function EventDetailScreen() {
const navigation = useNavigation();
const { id } = useLocalSearchParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [eventData, setEventData] = useState<Event|null>(null);
function updateField(field: keyof Event, value: string|Date) {
setEventData(prev => ({
...prev!,
[field]: value
}))
}
const onDelete = useCallback(async() => {
if(!eventData) {
return;
}
try {
Alert.alert('Delete Event', 'Are you sure you want to delete this event?',[{
text: 'Cancel',
}, {
text: 'Delete', onPress: async () => {
setIsSubmitting(true);
await eventService.deleteOne(id as string);
router.back();
}
}]);
} catch (error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
router.back();
} finally {
setIsSubmitting(false);
}
}, [eventData, id]);
async function onSubmitChanges() {
if(!eventData) {
return
}
try {
setIsSubmitting(true);
await eventService.updateOne(eventData.id,
eventData.name,
eventData.location,
eventData.startDate);
router.back();
} catch(error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
} finally {
setIsSubmitting(false);
}
}
const fetchEvent = async ()=> {
try {
setIsSubmitting(true);
const response = await eventService.getOne(id as string);
setEventData(response.data);
} catch (error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
router.back();
} finally {
setIsSubmitting(false);
}
};
useFocusEffect(useCallback(()=>{
fetchEvent()
}, []))
useEffect(()=>{
navigation.setOptions({
headerTitle: '',
headerRight: ()=> headerRight(onDelete)
})
}, [navigation, onDelete])
return (
<VStack m={20} flex={1} gap={30}>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Name
</Text>
<Input
value={eventData?.name}
onChangeText={(value)=>updateField('name', value)}
placeholder='Name'
placeholderTextColor='darkgray'
h={48}
p={14}
/>
</VStack>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Location
</Text>
<Input
value={eventData?.location}
onChangeText={(value)=>updateField('location', value)}
placeholder='Location'
placeholderTextColor='darkgray'
h={48}
p={14}
/>
</VStack>
<VStack gap={5}>
<Text ml={10} fontSize={14} color='gray'>
Date
</Text>
<DateTimePicker
onChange={(value)=>updateField('startDate', value || new Date())}
currentDate={new Date(eventData?.startDate|| new Date())}
/>
</VStack>
<Button
mt={'auto'}
isLoading={isSubmitting}
disabled={isSubmitting}
onPress={onSubmitChanges}
>
Save Change
</Button>
</VStack>
);
}
const headerRight = (onPress: VoidFunction) => {
return <TabBarIcon size={30} onPress={onPress} name='trash'/>
}
除了畫面的部份,這邊一樣使用 useFocusEffect 來處理畫面載入資後的資料處理。
使用 useEffect 來處理 navigation 與刪除事件的綁定。比較特別的是在刪除畫面的處理多一個 confirm 的頁面用來提醒 admin 即將刪除某個 event 才作刪除。
到此為止已經完了 admin 身份的操作
目前實做了關於活動操作的部份,剩下票券的部份將在後面的章節繼續說明。